Add BackendOption loading to swift bindings (#18855)#18855
Add BackendOption loading to swift bindings (#18855)#18855metascroy wants to merge 6 commits intopytorch:mainfrom
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/18855
Note: Links to docs will display an error until the docs builds have been completed. ❌ 2 New Failures, 3 Unrelated FailuresAs of commit 3f8d2ce with merge base e84a418 ( NEW FAILURES - The following jobs have failed:
BROKEN TRUNK - The following jobs failed but were present on the merge base:👉 Rebase onto the `viable/strict` branch to avoid these failures
This comment was automatically generated by Dr. CI and updates every 15 minutes. |
|
@metascroy has exported this pull request. If you are a Meta employee, you can view the originating Diff in D100710833. |
This PR needs a
|
2a5c762 to
52b3fc8
Compare
Summary:
Add backend options support to the ExecuTorch Swift/ObjC bindings.
The C++ Module class supports LoadBackendOptionsMap for passing per-delegate configuration (e.g. compute unit, thread count, cache directory) at model load time, but this was not exposed through the Swift/ObjC layer. This diff adds:
A new ExecuTorchBackendOption ObjC class (BackendOption in Swift) representing a single key-value configuration option with support for boolean, integer, and string value types.
New load and loadMethod overloads on ExecuTorchModule that accept a dictionary mapping backend identifiers to arrays of backend options.
Swift extensions providing idiomatic load(backendOptions:verification:) and load(_:backendOptions:) APIs on Module.
```
let module = Module(filePath: "model.pte")
try module.load(backendOptions: [
"CoreMLBackend": [
BackendOption("compute_unit", "cpu_and_gpu"),
BackendOption("num_threads", 4),
BackendOption("use_cache", true),
]
])
```
Differential Revision: D100710833
52b3fc8 to
25361b3
Compare
Summary: Add backend options support to the ExecuTorch Swift/ObjC bindings. The C++ Module class supports LoadBackendOptionsMap for passing per-delegate configuration (e.g. compute unit, thread count, cache directory) at model load time, but this was not exposed through the Swift/ObjC layer. This diff adds: A new ExecuTorchBackendOption ObjC class (BackendOption in Swift) representing a single key-value configuration option with support for boolean, integer, and string value types. New load and loadMethod overloads on ExecuTorchModule that accept a dictionary mapping backend identifiers to arrays of backend options. Swift extensions providing idiomatic load(backendOptions:verification:) and load(_:backendOptions:) APIs on Module. Current available options for CoreML backend are here: https://github.com/pytorch/executorch/blob/main/backends/apple/coreml/runtime/include/coreml_backend/coreml_backend_options.h ``` let module = Module(filePath: "model.pte") try module.load(backendOptions: [ "CoreMLBackend": [ BackendOption("compute_unit", "cpu_and_gpu"), BackendOption("_use_new_cache", true), ] ]) ``` Differential Revision: D100710833
|
@metascroy has imported this pull request. If you are a Meta employee, you can view this in D100710833. |
| } | ||
|
|
||
| func testLoadWithBackendOptions() throws { | ||
| guard let modelPath = resourceBundle.path(forResource: "add", ofType: "pte") else { |
There was a problem hiding this comment.
Does this pass? Where is add genrated?
| XCTAssertEqual(outputs?.first?.tensor(), Tensor([Float(2)])) | ||
| } | ||
|
|
||
| // Regression test: when load(_:BackendOptionsMap) is followed by a lazy |
There was a problem hiding this comment.
This comment is too specific to previous failing beahvior. Either fix old tests with add fiture or just do CoreML fixture
| // INVARIANT: this ivar is only ever overwritten with another non-nil | ||
| // BackendOptionsMap, and never reset to nil while `_module` is alive. | ||
| // Resetting it to nil would release the C++ map underneath `_module`'s | ||
| // borrowed pointer and re-introduce the dangling-pointer bug class this |
There was a problem hiding this comment.
This not specifcy "this refactor" in comment
|
@claude review this code |
|
Claude finished @metascroy's task in 2m 9s —— View job Code Review: Add BackendOption loading to Swift bindings
Overall AssessmentThis is a well-structured addition that properly bridges the C++ Issues Flagged in Existing Review CommentsI agree with the self-review feedback from @metascroy: 1. The error message hardcodes let message = "\(name).\(type) not bundled."2. The test at line 266 uses the 3. Long regression comment before The 10-line comment block is very detailed about internal crash mechanics. Consider trimming to 2-3 lines explaining what scenario this guards against, and leave the full debugging narrative in the commit message. Per CLAUDE.md: "Minimal comments; code should be self-documenting." 4. Having both is standard ObjC convention (designated initializer + class factory), but the factory method doesn't add value here since it's a one-liner that calls the init. If you want to keep the ObjC pattern, it's fine — but if this is primarily consumed via Swift (where 5. "this refactor" in comment ( The comment says "...the dangling-pointer bug class this refactor was created to eliminate." The phrase "this refactor" references a temporal event and will become meaningless over time. Rewrite to state the invariant without referencing the PR: // Resetting to nil would release the C++ map underneath _module's
// borrowed pointer, causing a dangling-pointer crash.Additional Findings6. Integer range check is a no-op on LP64 ( if (opt.intValue < INT_MIN || opt.intValue > INT_MAX) {
return Error::InvalidArgument;
}On 64-bit Apple platforms, 7. The PR description shows: try module.load(backendOptions: [
"CoreMLBackend": [BackendOption("compute_unit", "cpu_and_gpu")]
])But the actual Swift API requires a two-step construction: let options = try BackendOptionsMap(options: [...])
try module.load(options)This is arguably better (construct once, reuse many times), but the PR description is misleading. Consider adding a convenience 8. The comment says the C++ 9. Missing Minor: The 10. Test This test uses Code Quality
SummaryThe implementation is solid. The main actionable items from the self-review are straightforward comment/message fixes. The most architecturally important thing to verify is point #8 — ensuring |
Summary: Add backend options support to the ExecuTorch Swift/ObjC bindings. The C++ Module class supports LoadBackendOptionsMap for passing per-delegate configuration (e.g. compute unit, thread count, cache directory) at model load time, but this was not exposed through the Swift/ObjC layer. This diff adds: A new ExecuTorchBackendOption ObjC class (BackendOption in Swift) representing a single key-value configuration option with support for boolean, integer, and string value types. New load and loadMethod overloads on ExecuTorchModule that accept a dictionary mapping backend identifiers to arrays of backend options. Swift extensions providing idiomatic load(backendOptions:verification:) and load(_:backendOptions:) APIs on Module. Current available options for CoreML backend are here: https://github.com/pytorch/executorch/blob/main/backends/apple/coreml/runtime/include/coreml_backend/coreml_backend_options.h ``` let module = Module(filePath: "model.pte") try module.load(backendOptions: [ "CoreMLBackend": [ BackendOption("compute_unit", "cpu_and_gpu"), BackendOption("_use_new_cache", true), ] ]) ``` Differential Revision: D100710833
97245bb to
78a1494
Compare
|
@claude review this code |
|
Claude finished @metascroy's task in 2m 34s —— View job Code Review: Add BackendOption loading to Swift bindings
Overall AssessmentThis is a well-structured addition that correctly bridges the C++ The code quality is high: immutable value types, proper Issues from Self-Review Comments1. 2. The class factory 3. The comment block is well-written and documents the key invariant clearly. However, the original review flagged "this refactor" language — I see it's already been removed in the current version. The current text is clean. Additional Findings4. Potential issue: At
But there's a subtle edge case: if a user calls Scenario: [module loadWithOptions:optionsA error:&err]; // _module->backend_options_ = &optionsA.cppMap
// No methods loaded yet (lazy)
[module loadMethod:@"preprocess" options:optionsB error:&err];
// _loadedBackendOptions = optionsB → releases optionsA
// _module->backend_options_ still points at freed optionsA storage!
[module forward:...]; // lazy loads "forward" via backend_options_ → dangling pointerRecommendation: Either:
The simplest fix is option 3 — don't assign - (BOOL)loadMethod:(NSString *)methodName
options:(ExecuTorchBackendOptionsMap *)options
error:(NSError **)error {
NSParameterAssert(options);
// load_method consumes backend_options synchronously; no retention needed.
const auto errorCode = _module->load_method(methodName.UTF8String,
/*planned_memory=*/nullptr,
/*event_tracer=*/nullptr,
[options cppMap]);
...
}But then the caller must ensure 5. Integer range check is a no-op on 32-bit ( if (opt.intValue < INT_MIN || opt.intValue > INT_MAX) {
return Error::InvalidArgument;
}On 64-bit Apple platforms (the only targets currently supported), 6. _map = std::move(map);If 7. Missing test for The Swift extension exposes 8. BackendOption("too_big", Int(Int32.max) + 1)This would overflow on a 32-bit platform. Fine for Apple's current 64-bit-only targets, just noting for completeness. Minor Nits
SummaryThe implementation is solid. The one actionable concern is point #4 — the |
|
@claude review this PR |
|
Claude finished @metascroy's task in 2m 32s —— View job Code Review: Add BackendOption loading to Swift bindings
Overall AssessmentThis is a well-designed addition. The key architectural decision — retaining The code quality is high: immutable value types, proper Critical Issue
I verified the implementation at Suggestions1. Since Swift consumers use 2. Move semantics of The comment at lines 145-151 correctly explains why 3. Currently all validation failures return the same 4. The This is the correct choice for an immutable API, but worth noting that the snapshot is built eagerly at init time (lines 158-162). For large option dictionaries this doubles memory briefly during construction. Not a real concern for the typical 1-5 options per backend, but good to be aware of. 5. Test coverage gap: no non-CoreML test for All func testLoadWithBackendOptionsOnNonDelegatedModel() throws {
guard let modelPath = resourceBundle.path(forResource: "add", ofType: "pte") else {
XCTFail("Couldn't find the model file")
return
}
let module = Module(filePath: modelPath)
let options = try BackendOptionsMap(options: [
"SomeBackend": [BackendOption("key", "value")]
])
XCTAssertNoThrow(try module.load(options))
let outputs: [Value] = try module.forward(Tensor<Float>([1]), Tensor<Float>([2]))
XCTAssertEqual(outputs.first?.tensor(), Tensor([Float(3)]))
}6. Package.swift uses This is valid but somewhat unusual for a 7. ObjC test The Swift version ( Minor Nits
SummaryThe implementation is solid and production-ready. The lifetime management is the hardest part of this design and it's handled correctly with proper documentation and regression tests. The main suggestion is adding a non-CoreML smoke test so local developers get at least one backend-options API test running without CoreML python deps. |
shoumikhin
left a comment
There was a problem hiding this comment.
Reviewed in detail with focus on real correctness/safety issues over style. Inline comments cover the highest-ROI items:
- Lifetime:
loadWithOptions:rollback + verification request for theload_methodretain claim - Validation: duplicate keys and empty keys silently accepted; all failures collapse to one error code
- Swift API:
load(_:)autocomplete collision with the existing String overload
Solid foundation otherwise — the _loadedBackendOptions lifetime model is sound, length math is correct (>= against buffer size catches the NUL-overflow case), the two-phase-commit init is clean, conventions match the rest of the bindings.
Test gaps worth adding (not inline since they're additions, not changes to existing lines):
BackendOptionsMap.optionssnapshot accessor is never read in any test. The "deep-immutable snapshot" promise is unverified. Add: build withNSMutableDictionary+NSMutableArray, mutate after init, assert.optionsunchanged.- Boundary lengths exactly at the limit never tested. Tests use 256 / 4096 (well over). Add
kMaxOptionKeyLength - 1(max valid) andkMaxOptionKeyLength(first invalid) to pin the off-by-one math atBackendOptionsMap.mm:63. - Multiple backends in one map never tested — every test uses one backend key. Add
["CoreMLBackend": [...], "XNNPACK": [...]]to exercise the outer loop. - ObjC
loadMethod:options:error:has no test (only Swift covers it). - Validation error code identity never inspected — tests assert "throws" but never check the code is
.invalidArgument, so a buggy code mapping would still pass.
Skipping: Sendable conformance (forward-looking, not a current bug), Double overload binding (theoretical), various style nits (debugDescription, NSCopying, designated-init markers, header/.mm initializer asymmetry) — happy to expand on any if useful, but didn't want to drown the signal.
| // valid for the lifetime of any methods loaded with these options. | ||
| // (Methods load lazily during forward(), so the borrow may outlive this | ||
| // call.) See ExecuTorchBackendOptionsMap.h for the lifetime contract. | ||
| _loadedBackendOptions = options; |
There was a problem hiding this comment.
_loadedBackendOptions = options happens before the C++ load call, with no rollback if _module->load(...) returns non-Ok.
If a previous successful loadWithOptions: had installed a different map and the C++ Module retained internal references to the old map's storage on the failed reload path, that old map is already released by the time you observe the error here.
Suggest: assign only after the load succeeds, or keep both refs until the C++ call returns Ok.
| // but the C++ Module's `backend_options_` raw pointer (set by | ||
| // -loadWithOptions:) would still reference that released map's | ||
| // storage — a use-after-free on the next lazy load_method. | ||
| const auto errorCode = _module->load_method(methodName.UTF8String, |
There was a problem hiding this comment.
The "consumes synchronously, doesn't cache" claim about Module::load_method is the load-bearing invariant for the no-retain decision here. The reasoning is correct in the comment above (lines 378–385), but it's worth pinning explicitly.
Could you cite the specific line/range in module.cpp where load_method consumes the borrow? That way a future refactor of load_method can't silently break this without a test failure. Adding an ObjC test that creates options, calls loadMethod:options:, then nils the local options reference, then calls executeMethod: would also lock the contract behaviorally.
| break; | ||
| } | ||
| } | ||
| opts.push_back(bo); |
There was a problem hiding this comment.
Two real validation gaps in this loop:
- Duplicate keys within a single backend (e.g.
[BackendOption("k", 1), BackendOption("k", 2)]) are silently accepted — both get pushed intoopts, and the C++set_optionresolves to last-write-wins (or undefined depending on backend). Suggest astd::unordered_set<std::string>of seen keys per backend, returningError::InvalidArgumenton duplicate. - Empty key
""passes the length check at line 63 (strlen("") = 0 < 64). Almost certainly a caller bug. Reject ifstrlen(keyCStr) == 0.
Same applies to the empty backend identifier "" at line 48 — currently passes through to C++ set_options.
| const auto buildError = buildBackendOptionsMap(options, storage, map); | ||
| if (buildError != Error::Ok) { | ||
| if (error) { | ||
| *error = ExecuTorchErrorWithCode((ExecuTorchErrorCode)buildError); |
There was a problem hiding this comment.
All validation failures collapse to a single ExecuTorchErrorCode.invalidArgument (raised from lines 49, 58, 64, 80, 87, 91). Callers can't distinguish "key too long" vs "value too long" vs "int overflow" vs "non-UTF8 string" vs (if added) "duplicate key."
The test at ModuleTest.swift:339-359 shows this in practice: just XCTAssertThrowsError, no error inspection — because there's nothing distinguishable to check.
Suggest one of:
- Richer
NSErroruserInfo(include the offending key/value name + reason inlocalizedDescription) - Distinct
NS_ERROR_ENUMcodes per failure mode
This is the highest-ROI improvement for API users.
| /// `try BackendOptionsMap(["CoreMLBackend": [BackendOption("compute_unit", "cpu_and_gpu")]])`. | ||
| /// - verification: The verification level to apply when loading the program. | ||
| /// - Throws: An error if loading fails. | ||
| func load( |
There was a problem hiding this comment.
Module.load(_ options: BackendOptionsMap, verification:) here, plus the existing loadMethod:error: (bridged to Swift as load(_:) at ExecuTorchModule.h:233), produce an autocomplete collision: module.load(someMap) and module.load("forward") both compile cleanly with no first-positional label hint.
Suggest renaming the first label: load(options:) and load(_ method:, options:) (i.e. add the label to options: here and on the sibling at line 76). One keystroke at the call site, eliminates a real ambiguity for anyone using autocomplete.
Summary:
Add backend options support to the ExecuTorch Swift/ObjC bindings.
The C++ Module class supports LoadBackendOptionsMap for passing per-delegate configuration (e.g. compute unit, thread count, cache directory) at model load time, but this was not exposed through the Swift/ObjC layer. This diff adds:
Current CoreML backend options are documented here: https://github.com/pytorch/executorch/blob/main/backends/apple/coreml/runtime/include/coreml_backend/coreml_backend_options.h
Swift usage:
Differential Revision: D100710833